UK parity increment 4: weighted integrity gates (input-mass parity + QRF tail concentration) in the terminal battery (#609) - #610
Conversation
…ry (#609) Port the two incident-purchased US integrity gates to the UK per #609 (increment 4 of the #578 parity plan): - Promote us_input_mass_totals to shared populace.build.input_mass (US import path re-exports; no fork). - New uk_runtime.weighted_integrity: uk_dataset_input_mass_totals over the national tables, uk_input_mass_parity_gate (verbatim #278 semantics plus the universal stale-fails exclusion discipline the shared gate lacks, with the frozen reference identity recorded), and uk_qrf_tail_concentration_gate whose surface derives from the fit_weighted_qrf_stage* outputs declared in the HMRC source manifest — no sparsity filter, min_nonzero_records is the sole thinness guard. - Both gates join uk_terminal_gate_report evidence-gated: unarmed paths omit them; an armed gate missing its reference or measured thresholds fails closed by name. Thresholds have no committed defaults (the #609 measurement pass owes them) and are sealed into policy_sha256 with new input_mass_parity / qrf_tail_concentration evidence digests. Report schema 3, attestation 5; populace-data publication contract moved in lockstep, including detail-schema and stale-exclusion observables. - Committed empty reviewed-exclusion registers, --input-mass-* / --qrf-tail-* staging launcher flags, a measurement tool (measure_uk_weighted_integrity_baselines.py), and an --emit-weighted-totals extraction for the pinned eFRS incumbent that refuses to write inside the repo pending the UKDS EUL confirmation. - Mutation tests to the increment-1 standard: zeroed, 99.9%-loss, and concentrated columns each fail by name through the armed battery; a crash inside either evaluator appends a named failure without masking pending gates; dropping an evidenced gate fails membership validation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sting The measurement recorder exists so its numbers can be posted on #578, which makes its output a publication under UK Data Service End User Licence CD137 v16.00 clause 8: "To adhere to the statistical disclosure control standards as detailed in the Research data handling and security guide for users in any outputs I produce and publish." CD171-ResearchDataHandling §5.2.1 sets those standards for Safeguarded data — never report cells based on one or two cases (minimum threshold of 3, with 10 advised where several outputs share a source), and "any output that refers to unit records, e.g. a maximum or minimum value, must be avoided". The recorder violated both in spirit: it emitted a per-column max_abs_value, and it would have reported a top-k concentration share for a column with a handful of carriers. Tail concentration is the (n, k) dominance statistic, so at small k the share times the total approximates the mean of those few records. Now: no maximum or minimum is emitted at all; a column with fewer carriers than --sdc-minimum-count (default 10) reports no shares, no carrier count, and no nonzero share; a --top-k narrower than that count is refused; and the payload records the rules applied plus the clause 11-12 citation obligations. Per-column weighted totals aggregate every carrier and remain unconditional. A concentrated column above the threshold is still fully visible, so the #462 signature the gate exists to catch is undimmed. Separately, uk_dataset_input_mass_totals resolved a benunit's weight through whichever member person came first, silently assuming benunits nest inside a single household. The effective-mass coverage gate enforces that invariant, but it is a different gate in a batched report, so this path now validates it and fails closed by name instead of totalling a split benunit against one of its households. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
--emit-weighted-totals ran after the reference writer, whose --output defaults to the committed efrs_parity_reference.json. So measuring the incumbent also regenerated the coverage contract against whatever policyengine-uk happened to be installed — silently, as a side effect. With policyengine-uk 2.89.4 locally against the committed 2.89.0 (871 engine variables vs the recorded 866) that is a live diff, and the operator running the #609 measurement pass has no reason to expect a contract rewrite. --emit-weighted-totals is now its own mode: it writes the gate input outside the repository and exits, leaving the committed reference untouched. Regenerating the contract stays a separate, deliberate run. With policyengine-uk installed the extraction is testable for the first time, so it now has one: a synthetic three-table H5 with the incumbent's shape and the sha pin stubbed proves the weight broadcast (distinct per-household weights, so a broadcast error cannot pass), the exclusion of structural and non-engine columns, the pinned identity block, that the emitted file loads straight into uk_input_mass_parity_gate as a reference, and that a totals run leaves the committed reference byte-identical. That path was previously unexercised in the workspace and was about to be run against licensed data. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…609) Ran the reference extraction against the pinned enhanced-FRS incumbent (sha 584ae33d…, verified before reading) and measured its own concentration baseline. The findings are written into the module docstring because they are the reason the gates ship unarmed, and a later reader with the US constants in hand would otherwise reintroduce them: - The US 0.75 top-share threshold fails the incumbent on 16 of 28 checked columns at the US top_k/min_nonzero settings. UK reported-benefit columns sit on small, high-intensity subpopulations, so concentrated weighted mass is their normal state. - No single global top-share threshold is both incumbent-compatible and incident-catching: the lowest no-headroom value on a broad surface is ~0.996, which would have missed the #462 incident itself (89% of mass in 100 of 2,295 carriers). - The incumbent cannot supply a QRF baseline at all. Eleven stage-1 outputs and five stage-2 disability columns are absent from it, and the seven stage-1 columns it carries hold survey-reported FRS values rather than donor draws, so that is a different distribution from the one the gate polices. The QRF threshold needs a staged candidate. - A magnitude floor is not a coherent materiality filter on this surface, which mixes currency totals in the trillions with weighted person counts and flag counts. The US 1e9 floor would stop checking 50 of 131 columns, including gift_aid and charitable_investment_gifts — the two the release input coverage manifest requires distributional effective mass for. A 0.0 floor still skips the three exact-zero reference columns, since the shared gate compares <=. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The certified compact is not a valid candidate against the pinned incumbent, which confirms the issue's reading of reference option 2. Measured: 8.1% less household mass (28,840,551 against 31,389,678), 150 columns against the reference's 131 with 22 candidate-only, and per-column drift with a 22.3% median and a 578% maximum — a differently calibrated build rather than a mass-preserving derivative. It also zeroes gift_aid and charitable_investment_gifts and carries none of the eleven hmrc_spi_* columns, because it is the input to the HMRC/SPI stage that creates them: those are stage-1 QRF outputs. Comparing the two reproduces the #327 failure mode on the restoration surface. So both thresholds need a staged candidate, and the adjudicated 0.0 mass floor stays uncommitted as a constant because a policy arms both together. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…609) The comparison production actually runs is shipped-release against next shipping of the same kind — the _export_input_mass_gate pairing whose reference #327 adjudicated to a certified release. The only consecutive certified UK pair (72aeefc 2026-06-11 -> dd68c73 2026-06-19, both sha-verified) measures it: - The #278 incident class passed cleanly: zero columns dropped, zero zeroed, four added (reported, never failing). The gate's core protection needs no threshold judgment and would not have false-alarmed on a real shipping. - A drift tolerance cannot be tight yet: 143 shared nonzero columns moved with median |drift| 10.52% and max +2,973.71% (person.adult_ema), dominated by intentional reported-benefit repopulation. Tolerances between 5% and 50% would have failed 22-89 columns of a reviewed shipping. The no-headroom release-arm boundary is the pair's exact maximum, 29.737060, ratcheting down as consecutive same-code shippings stabilise — never up. Recorded in the module docstring next to the other measurement findings so the release-arm wiring (items 6/8) arms from evidence, not from the US 0.5. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…al tests Review asked whether the UK port reinvented anything. The audit: gates.py and the US release tool are byte-identical to main, both UK wrappers call the shared gate functions, and us_input_mass_totals is the same function object as the promoted helper. Three differential tests now lock that equivalence against future drift: - Equivalent records expressed as a populace Frame (the US path) and as UK national tables produce identical per-column weighted totals, pinned once to a hand computation so both paths anchor to the semantics, not merely to each other. - Without exclusions, uk_input_mass_parity_gate reproduces the shared gate's verdict, failure lines, and details verbatim; the UK result may only add reference_identity and the stale/dormant register fields #609 requires. - With exclusions, uk_qrf_tail_concentration_gate matches the shared tail gate's verdict, failures, and details exactly, differing only in the UK battery gate name. The deliberate deltas stay what the issue mandated: stale-exclusion failures on the input-mass side (the shared gate skips excluded columns) and no sparsity filter on the QRF surface. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MaxGhenis
left a comment
There was a problem hiding this comment.
Thorough adversarial pass (same battery every populace PR gets this week). The core of this PR is right — the shared-helper promotion keeps US call sites byte-identical, the UK household-weight broadcast checks out against hand computation at fixture scale, and the differential tests genuinely pin the UK wrappers to the shared gate semantics. The blockers are all in one family, and it's the same family we've been curing across the repo this week (see the #616 review trail): places where the gate's authority over its own inputs is incomplete, so absence or malformation slips through as success.
1. Legacy output path bypasses the fail-closed HMAC contract (pre-existing, but it now swallows your new attestations too). _AttestedUKTerminalGateReport inherits a .passed that checks only functional gates (gates.py:143); with signing credentials unset, the serialized payload correctly fails — but the legacy --input-coverage-json branch skips the raising writer, trusts the object property, and proceeds to staging output (national_build.py:443). Repro on the green four-row fixture with the key unset: report.passed == True while report.report_payload()["passed"] == False with a live RuntimeError in _signing_error.
2. A declared-but-absent QRF output produces a passing zero-column gate. Absent/nonnumeric declared outputs are recorded but omitted from the maps handed to the shared gate (weighted_integrity.py:638, :667), and publication checks only stale exclusions (contract.py:1322). Repro: passed=True, columns_checked=0, absent_columns=['declared_but_absent']. Declared-but-missing should be a named failure — this is exactly the "family silently vanishes" class that run 7 just demonstrated at production scale on the US side.
3. The receipted/expiring exclusion contract from the PR body isn't in the schema. It's column -> reason only (weighted_integrity.py:155) — no approval identity, no expiry, so an exclusion is immortal. 4. Two adjacent leaks: a present column thinner than min_nonzero_records exits before exclusion classification (gates.py:2459 — its exclusion appears in no collection: repro thin={'person.x': 4}, passed=True, reviewed={}, stale=[], dormant=[]), and the plain json.loads + str() loader accepts duplicate keys, nulls, and non-string names.
Suggested path: #616's fix rounds are landing a canonical-authority pattern (module-level plan/surface authorities, live-content digests receipted into gate manifests, no caller substitution) that solves 2–4 as a class — happy to converge on that machinery rather than have you build a parallel one; it should be on main within a day. Finding 1 is a scoped fix in the legacy branch (route it through the raising writer). Everything else here is merge-ready once those close.
…reporting, per-country gate selection (#611) (#619) * Country-agnostic gate battery core: phased evaluation, unconditional reporting, per-country selection (#611) The tooling half of #611 increment 1. populace.build.gate_battery evaluates a country's declared gate selection phase by phase, resolves every declared entry to exactly one of five statuses (passed / failed / not_applicable / evidence_absent / unreached), persists the full attested report before any blocking decision, and hashes every declared threshold into the policy digest. gates.json gains a per-country phases order and per-entry phase; thresholds live in entry parameters; a reviewed not_applicable reason turns a deliberately unselected gate into a reported receipt. The Belgian spec migrates mechanically (nine entries, all terminal) and a compatibility test proves it runs as declared, with unimplemented gate names surfacing as named evidence_absent gaps rather than crashes. The UK terminal battery is untouched; its migration onto this executor is the consumer half, sequenced after #610 and the #612 Frame carrier. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Derive gate battery provenance from the evaluated manifest * Freeze gate parameters before battery evaluation * Format gate battery changes and extend changelog --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Max Ghenis <mghenis@gmail.com>
|
Pushed the review remediation on your branch (María — your gate design and naming are intact; ten fix commits): the legacy |
MaxGhenis
left a comment
There was a problem hiding this comment.
Findings closed within scope (receipts in the fix comment); the exclusion-schema design stays with María per the TODO. Approving to dismiss my earlier request-changes.
…ning), #616 Brings Max's #617 adversarial-review fixes (preflight alias rejection, fail-closed SDC floor, per-artifact audit isolation, strict JSON) and the merged #610 base under this branch; the PR retargets main. # Conflicts: # changelog.d/609-uk-weighted-integrity-gates.added.md # changelog.d/612-uk-frame-foundations.added.md # packages/populace-build/src/populace/build/uk_runtime/hmrc_source_contract.py # packages/populace-build/src/populace/build/uk_runtime/terminal_gates.py # packages/populace-build/src/populace/build/uk_runtime/weighted_integrity.py # packages/populace-build/tests/test_uk_efrs_weighted_totals.py # packages/populace-build/tests/test_uk_frame_linkage_preflight.py # packages/populace-build/tests/test_uk_rowwise_dataset.py # packages/populace-build/tests/test_uk_terminal_gates.py # packages/populace-build/tests/test_uk_weighted_integrity.py # packages/populace-data/src/populace/data/contract.py # tools/build_uk_national_dataset.py # tools/preflight_uk_frame_linkage.py
…ulace Frames (stacked on #610 + #617) (#618) * UK parity increment 4: weighted integrity gates in the terminal battery (#609) Port the two incident-purchased US integrity gates to the UK per #609 (increment 4 of the #578 parity plan): - Promote us_input_mass_totals to shared populace.build.input_mass (US import path re-exports; no fork). - New uk_runtime.weighted_integrity: uk_dataset_input_mass_totals over the national tables, uk_input_mass_parity_gate (verbatim #278 semantics plus the universal stale-fails exclusion discipline the shared gate lacks, with the frozen reference identity recorded), and uk_qrf_tail_concentration_gate whose surface derives from the fit_weighted_qrf_stage* outputs declared in the HMRC source manifest — no sparsity filter, min_nonzero_records is the sole thinness guard. - Both gates join uk_terminal_gate_report evidence-gated: unarmed paths omit them; an armed gate missing its reference or measured thresholds fails closed by name. Thresholds have no committed defaults (the #609 measurement pass owes them) and are sealed into policy_sha256 with new input_mass_parity / qrf_tail_concentration evidence digests. Report schema 3, attestation 5; populace-data publication contract moved in lockstep, including detail-schema and stale-exclusion observables. - Committed empty reviewed-exclusion registers, --input-mass-* / --qrf-tail-* staging launcher flags, a measurement tool (measure_uk_weighted_integrity_baselines.py), and an --emit-weighted-totals extraction for the pinned eFRS incumbent that refuses to write inside the repo pending the UKDS EUL confirmation. - Mutation tests to the increment-1 standard: zeroed, 99.9%-loss, and concentrated columns each fail by name through the armed battery; a crash inside either evaluator appends a named failure without masking pending gates; dropping an evidenced gate fails membership validation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Disclosure-control the #609 measurement recorder; validate benunit nesting The measurement recorder exists so its numbers can be posted on #578, which makes its output a publication under UK Data Service End User Licence CD137 v16.00 clause 8: "To adhere to the statistical disclosure control standards as detailed in the Research data handling and security guide for users in any outputs I produce and publish." CD171-ResearchDataHandling §5.2.1 sets those standards for Safeguarded data — never report cells based on one or two cases (minimum threshold of 3, with 10 advised where several outputs share a source), and "any output that refers to unit records, e.g. a maximum or minimum value, must be avoided". The recorder violated both in spirit: it emitted a per-column max_abs_value, and it would have reported a top-k concentration share for a column with a handful of carriers. Tail concentration is the (n, k) dominance statistic, so at small k the share times the total approximates the mean of those few records. Now: no maximum or minimum is emitted at all; a column with fewer carriers than --sdc-minimum-count (default 10) reports no shares, no carrier count, and no nonzero share; a --top-k narrower than that count is refused; and the payload records the rules applied plus the clause 11-12 citation obligations. Per-column weighted totals aggregate every carrier and remain unconditional. A concentrated column above the threshold is still fully visible, so the #462 signature the gate exists to catch is undimmed. Separately, uk_dataset_input_mass_totals resolved a benunit's weight through whichever member person came first, silently assuming benunits nest inside a single household. The effective-mass coverage gate enforces that invariant, but it is a different gate in a batched report, so this path now validates it and fails closed by name instead of totalling a split benunit against one of its households. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Stop the totals run from republishing the coverage contract (#609) --emit-weighted-totals ran after the reference writer, whose --output defaults to the committed efrs_parity_reference.json. So measuring the incumbent also regenerated the coverage contract against whatever policyengine-uk happened to be installed — silently, as a side effect. With policyengine-uk 2.89.4 locally against the committed 2.89.0 (871 engine variables vs the recorded 866) that is a live diff, and the operator running the #609 measurement pass has no reason to expect a contract rewrite. --emit-weighted-totals is now its own mode: it writes the gate input outside the repository and exits, leaving the committed reference untouched. Regenerating the contract stays a separate, deliberate run. With policyengine-uk installed the extraction is testable for the first time, so it now has one: a synthetic three-table H5 with the incumbent's shape and the sha pin stubbed proves the weight broadcast (distinct per-household weights, so a broadcast error cannot pass), the exclusion of structural and non-engine columns, the pinned identity block, that the emitted file loads straight into uk_input_mass_parity_gate as a reference, and that a totals run leaves the committed reference byte-identical. That path was previously unexercised in the workspace and was about to be run against licensed data. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Record what the incumbent measurement pass settled about thresholds (#609) Ran the reference extraction against the pinned enhanced-FRS incumbent (sha 584ae33d…, verified before reading) and measured its own concentration baseline. The findings are written into the module docstring because they are the reason the gates ship unarmed, and a later reader with the US constants in hand would otherwise reintroduce them: - The US 0.75 top-share threshold fails the incumbent on 16 of 28 checked columns at the US top_k/min_nonzero settings. UK reported-benefit columns sit on small, high-intensity subpopulations, so concentrated weighted mass is their normal state. - No single global top-share threshold is both incumbent-compatible and incident-catching: the lowest no-headroom value on a broad surface is ~0.996, which would have missed the #462 incident itself (89% of mass in 100 of 2,295 carriers). - The incumbent cannot supply a QRF baseline at all. Eleven stage-1 outputs and five stage-2 disability columns are absent from it, and the seven stage-1 columns it carries hold survey-reported FRS values rather than donor draws, so that is a different distribution from the one the gate polices. The QRF threshold needs a staged candidate. - A magnitude floor is not a coherent materiality filter on this surface, which mixes currency totals in the trillions with weighted person counts and flag counts. The US 1e9 floor would stop checking 50 of 131 columns, including gift_aid and charitable_investment_gifts — the two the release input coverage manifest requires distributional effective mass for. A 0.0 floor still skips the three exact-zero reference columns, since the shared gate compares <=. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Record the compact-vs-incumbent measurement result (#609) The certified compact is not a valid candidate against the pinned incumbent, which confirms the issue's reading of reference option 2. Measured: 8.1% less household mass (28,840,551 against 31,389,678), 150 columns against the reference's 131 with 22 candidate-only, and per-column drift with a 22.3% median and a 578% maximum — a differently calibrated build rather than a mass-preserving derivative. It also zeroes gift_aid and charitable_investment_gifts and carries none of the eleven hmrc_spi_* columns, because it is the input to the HMRC/SPI stage that creates them: those are stage-1 QRF outputs. Comparing the two reproduces the #327 failure mode on the restoration surface. So both thresholds need a staged candidate, and the adjudicated 0.0 mass floor stays uncommitted as a constant because a policy arms both together. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Measure the production release pair: the release-arm baseline exists (#609) The comparison production actually runs is shipped-release against next shipping of the same kind — the _export_input_mass_gate pairing whose reference #327 adjudicated to a certified release. The only consecutive certified UK pair (72aeefc 2026-06-11 -> dd68c73 2026-06-19, both sha-verified) measures it: - The #278 incident class passed cleanly: zero columns dropped, zero zeroed, four added (reported, never failing). The gate's core protection needs no threshold judgment and would not have false-alarmed on a real shipping. - A drift tolerance cannot be tight yet: 143 shared nonzero columns moved with median |drift| 10.52% and max +2,973.71% (person.adult_ema), dominated by intentional reported-benefit repopulation. Tolerances between 5% and 50% would have failed 22-89 columns of a reviewed shipping. The no-headroom release-arm boundary is the pair's exact maximum, 29.737060, ratcheting down as consecutive same-code shippings stabilise — never up. Recorded in the module docstring next to the other measurement findings so the release-arm wiring (items 6/8) arms from evidence, not from the US 0.5. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Pin UK gate behavior to the shared US implementations with differential tests Review asked whether the UK port reinvented anything. The audit: gates.py and the US release tool are byte-identical to main, both UK wrappers call the shared gate functions, and us_input_mass_totals is the same function object as the promoted helper. Three differential tests now lock that equivalence against future drift: - Equivalent records expressed as a populace Frame (the US path) and as UK national tables produce identical per-column weighted totals, pinned once to a hand computation so both paths anchor to the semantics, not merely to each other. - Without exclusions, uk_input_mass_parity_gate reproduces the shared gate's verdict, failure lines, and details verbatim; the UK result may only add reference_identity and the stale/dormant register fields #609 requires. - With exclusions, uk_qrf_tail_concentration_gate matches the shared tail gate's verdict, failures, and details exactly, differing only in the UK battery gate name. The deliberate deltas stay what the issue mandated: stale-exclusion failures on the input-mass side (the shared gate skips excluded columns) and no sparsity filter on the QRF surface. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Promote the engine-tables materializer into shared populace.frame.engine_tables Both engine adapters carried a private copy of the same boundary rule: typed weights are authoritative, and any existing {entity}_weight column is overwritten in place, never trusted. The UK carrier swap (#612) needs the same materializer without an adapter class, so the rule moves to one shared function; the axiom adapter delegates unchanged, and the PolicyEngine-US adapter pins weighted_entities=("household",) so its export contract keeps materializing household weights only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Require an explicit weight kind on in-memory UK rowwise inputs _dataset_tables defaulted a missing household_weight_kind to DESIGN, so an IMPORTANCE- or CALIBRATED-weighted in-memory dataset handed to the rowwise clone path was silently downgraded and written out with the wrong populace_household_weight_kind attribute. In-memory datasets now hard-fail on the absent attribute, mirroring the adjacent mass_log=None rejection; H5 inputs keep their documented attribute-less design default. Chartered under #612 to land before the carrier moves. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add the #612 UK Frame-linkage preflight: SDC-safe, read-only Frame construction enforces invariants the UK path never checked (group ids sorted ascending; membership equality in both directions, so a person-less benunit fails, not just a dangling reference). This tool classifies the real artifacts against every one of them before any migration code runs, and its output is publishable under UKDS EUL CD137 §8 / CD171 §5.2.1: threshold-guarded counts, booleans, and aggregates only; Frame exception text (which embeds real ids) is never echoed, and a planted-sentinel canary test holds that across stdout, stderr, and the JSON file. Reads are mode="r" throughout so the certified-candidate fingerprint survives. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * UK Frame carrier foundations: schema, provenance, loaders, one physical writer (#612) national_frame.py is the UK build's Frame construction seam: UK_NATIONAL_SCHEMA (the person table already uses Frame's exact membership convention, so zero renames), uk_national_frame (typed household weights built from the exported household_weight column, which stays on the table to preserve column order), the metadata accessors, validate_uk_national_frame (the UK residue: entity set, time period, column-vs-typed-vector agreement, mass-log total agreement), and UKStagingProvenance — provenance beside the frame, the UKCertifiedCandidateIdentity idiom, because Frame metadata rejects Path and Frame ops hard-construct Frame(...). load_uk_national_frame / write_uk_national_frame land beside the shadow carrier's loader and writer, sharing one extracted reader (suffix check, symlink resolve, fingerprint race guard) and one extracted physical writer (atomic temp+replace, tables + weight attrs landing together). write_uk_rowwise_dataset routes through the same physical writer, so the two artifacts can never drift in layout. Acceptance, all in CI on synthetic tables: a payload-identity test proving the Frame writer's artifact matches the shadow writer's (keys, column order, dtypes, values, root attrs — deliberately not bytes; HDF5 stamps write times), a write/load round trip with provenance, and the SPI-replacement frameability test — the mid-pipeline kill-shot showing stage 2's cloned/rebuilt output satisfies Frame's sorted-group-id and bidirectional-membership invariants. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * The UK rowwise clone path accepts a Frame input (#612) _dataset_tables gains a Frame branch: tables through the shared engine-tables materializer (typed weights authoritative), the weight kind from the typed weights themselves, and the mass log from the frame. Together with the earlier hard-fail on attribute-less in-memory carriers, the silent DESIGN downgrade is now unreachable from either input shape. UKRowwiseDatasetResult itself is unchanged — its own carrier swap is out of #612's first increment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Guard the preflight's H5 fixtures for the pytables-free wheel gate The wheel-packaging gate installs the built wheels into a clean venv without pytables, where the four H5-writing preflight tests failed at pd.HDFStore. They now importorskip tables and h5py, the repo convention for H5-backed tests; the SDC masking test needs neither and still runs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * The swap: UK national stages run Frame -> Frame (#612 commit 3) UKNationalStage.transform is re-typed Callable[[Frame], Frame] and the build loop loads (frame, provenance), validates the UK residue after each stage, and feeds gate evidence as engine_tables(frame) — a plain mapping all three gate families already accept, so terminal_gates.py and weighted_integrity.py are untouched (#611 owns their typing). The certified-candidate fence moves onto the provenance record: the build binds it to any stage exposing bind_staging_provenance, and _assert_provenance_matches_certified_candidate fails closed on an unbound run — the same strength the smuggled _source_h5 fields gave, without a carrier to smuggle them in. Stage bodies follow the mass rule documented in national_frame.py: the retained-leaves stage (person-only replacement) hard-constructs with the input's kind and mass log carried through; the SPI stage (reshapes tables AND advances DESIGN -> IMPORTANCE) hard-constructs with the support seam's reviewed conservation record. The calibration materializers take the national frame and read kind/period/tables through the accessors; their inner constraint-frame constructions are unchanged. uk_national_frame now also rejects a missing or blank time period at construction, where the old carrier's validator caught it. The shuffled FRS fixture keeps its teeth: group tables sort (Frame raises on unsorted ids, never reorders) while the person table stays shuffled, so lineage resolution must remain id-keyed — the 2024-25 FRS bug class the fixture exists to catch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Rowwise consumers load Frames; carriage proofs use the rowwise reader (#612 commit 4) tools/build_uk_rowwise_candidate.py loads the national input as a Frame and feeds it straight into the ladder clone (commit 2's Frame branch); the new construction and loading names are re-exported from the uk_runtime package additively. Converting the rowwise fixtures surfaced a boundary worth recording: the rowwise clone artifact is NOT a national frame — clone_index lives on every entity table, which Frame's global column-uniqueness rule rejects. The attr-carriage proofs therefore go through the rowwise seam's own reader (read_uk_single_year_weight_metadata), which is the contract they were always pinning, and survives the shadow carrier's retirement. UKRowwiseDatasetResult's own carrier design remains out of scope, now with a concrete reason on record. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Retire the UKNationalDataset shadow carrier (#612 commit 5) The class, its loader/writer pair, and validate_uk_national_dataset are deleted; the package re-exports swap to the Frame construction surface. Hard removal, no deprecation shim — populace is 0.1.0 pre-release, and a shim would keep the shadow type alive, which is what #612 exists to end. The payload test's dual-writer arm retires with the shadow writer; it now pins payload stability across write -> load -> write generations (keys, column order, dtypes, values, root attrs), which is the surface downstream readers actually depend on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add the payload comparator for the old-vs-new staging acceptance (#612) The instrument for the remaining pre-merge item: compares two UK single-year H5 artifacts over the payload surface downstream readers depend on — store keys in write order, per-table column order, dtypes, index type and values, row order, values, and root attrs — because byte identity was never attainable (HDF5 stamps write times). Thereafter it is the standing acceptance instrument for any writer or carrier refactor claiming the artifact did not change. Fail-closed per the #617 review discipline: left/right and --json-out alias rejection (resolved path + samefile, rechecked after the audit), an un-lowerable SDC floor, differing rows reported as threshold-guarded counts by column name (never values, canary-tested across stdout, stderr, and the JSON file), and strict JSON. Exit 0 identical, 1 differing, 2 unsafe configuration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Gate evidence carries the frame's metadata surface (#612 acceptance finding) The first credentialed acceptance build caught what CI's toy stages could not: the coverage gate's hmrc_spi_income family getattr-reads household_weight_kind, time_period, and mass_log off the evidence, and the plain engine_tables mapping silently failed all three to ''/() — kind '', period '', and a missing MassChangeRecord, three spurious gate failures on top of the real findings. The build loop now materializes a _UKGateEvidence adapter: the three engine tables plus the frame's metadata, exactly the shadow carrier's read surface, in one place, so the deliberately duck-typed gate modules (#611's charter) stay untouched. The seam test's coverage fake now asserts the metadata attrs so the gap cannot silently reopen. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Harden the payload comparator against its adversarial review findings Four verified false-negative holes and two fail-open surfaces, each now closed with a regression test: int64 values above 2**53 compared equal through a float64 cast (raw integer comparison now); a Series-vs- DataFrame stored-kind difference was normalized away despite corrupting what load_uk_national_frame parses (stored_kind is now payload); root attrs compared as str() masked mid-array and typed differences (raw- value equality, names-only reporting kept); index dtype and name were unchecked. sdc_count and compare_tables now validate the CD171 floor themselves, so a direct importer cannot disclose below-floor counts. An unreadable artifact no longer exits 1 (the payloads-differ verdict) with a raw traceback: the comparison is protected, exception text is suppressed, and exit 2 means no-verdict. The disclosure canary now also plants a sentinel inside the mass-log attr JSON. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Restore the fence's descent guarantee and the review's remaining findings (#618 adversarial review) The certified-candidate fence bound provenance to a mutable transform attribute with no linkage to the frame being transformed: a stale binding could fence a later run, and a pipeline whose first stage consumed a substituted frame passed as long as a matching load had happened once. bind_staging_provenance now binds the loaded frame too, the retained-leaves transform records its input, the SPI fence requires the pipeline to have started from the bound frame, and bindings are single-use — strictly stronger than the retired loader-attached design, which with_tables could carry past content replacement. Also from the review: the driver's post-stage validate_uk_national_frame seam gains its first direct test (a with_weights stage with a stale exported column — constructs fine, only the driver seam rejects); uk_national_frame stores the caller's exact time_period (validating on a stripped copy) instead of rewriting padded payload; the rowwise clone tests regain reader-side structural validation of the written bytes; the changelog fragment now declares the float64 weight-column canonicalization, the newly load-bearing Frame invariants, and the result-surface renames; and the test module docstring that still claimed a shadow-writer comparison arm points at the credentialed acceptance evidence instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Use ladder-appropriate reader-side checks in the ladder clone test The review-response commit applied validate_uk_rowwise_dataset_tables to both converted clone tests, but that validator demands the rowwise-geography column family and the ladder artifact carries the ladder's own columns — CI caught the mismatch. The ladder test now asserts the structural facts that apply to its artifact from the written bytes: entity row counts against the in-memory result, id uniqueness, and person->household linkage. The rowwise-geography test keeps the full rowwise validator, which is correct there. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Evict Finder .DS_Store droppings and gitignore them A directory-path git add in the review-response commit swept three untracked .DS_Store files into the branch; they are Finder metadata, not payload. Removed from tracking and ignored repo-wide so a future add cannot repeat this. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address the #618 review blockers: seam revalidation, pinned export schema, digest-bound receipt Blocker 1: Frame.table returns stored internals, so post-construction mutation could pass validate_uk_national_frame and reach export. The kernel gains Frame.revalidate() — every constructor invariant re-run against current state — and the UK validator calls it first at every seam, restoring the retired shadow validator's defensive recheck without re-duplicating kernel logic in UK land. The reviewer's six injected corruption classes are a parametrized regression test. Blocker 2: the export schema is now enforced — validate rejects frames carrying non-household typed weights (the loader refuses the reserved columns they would materialize) and link-bearing schemas (the writer would silently drop them); write_uk_national_frame additionally pins engine_tables(weighted_entities=('household',)). Blocker 3: the comparator compares dtype objects (a categorical ordered flag now fails with an explicit note) and digest-binds both inputs by sha256; the old-vs-new round-trip receipt is regenerated with the hardened comparator and committed as experiments/612-uk-carrier-payload-receipt.{json,md} — verdict, row counts, digests, and provenance, verifiable offline. The two artifacts' own digests differ while the payload is identical: the byte-vs-payload doctrine in one receipt. Plus ruff format on the flagged files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The committed uk/degenerate_reviewed_exclusions.json register is the degenerate-release-surface policy of record, carrying the #630 adjudication: the column is constant at the build vintage by construction — lineage plumbing for the rowwise clone's source_household_key that documents the vintage at the row level, not signal. A None argument to uk_terminal_gate_report resolves to the register (pass {} to run bare); stale entries still fail the gate, so the register cannot rot. Interim schema-1, matching the two existing registers; all three upgrade together when the #610 approval-identity/ receipt/expiry design lands. The frozen policy digest moved with the register — the intended tripwire — and the microcosm-data pin is now vintage-aware: new releases must attest the #630 policy while the grandfathered June release keeps attesting its own pre-#630 digest (defensive today, since the terminal-report checker only runs for exact-k ids). The driver gains --degenerate-exclusions as a review-time override, wired through the distinct-path check. Live receipt: the full-scale evidence run at seed 7 evaluated the battery with the register active and degenerate_release_surface passed with the exclusion recorded; only weight_ratio (1592.18 vs the 1151.25 maximum, its own #630 adjudication) still fails at full scale. Refs #630, #627, #610. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The committed uk/degenerate_reviewed_exclusions.json register is the degenerate-release-surface policy of record, carrying the #630 adjudication: the column is constant at the build vintage by construction — lineage plumbing for the rowwise clone's source_household_key that documents the vintage at the row level, not signal. A None argument to uk_terminal_gate_report resolves to the register (pass {} to run bare); stale entries still fail the gate, so the register cannot rot. Interim schema-1, matching the two existing registers; all three upgrade together when the #610 approval-identity/ receipt/expiry design lands. The frozen policy digest moved with the register — the intended tripwire — and the microcosm-data pin is now vintage-aware: new releases must attest the #630 policy while the grandfathered June release keeps attesting its own pre-#630 digest (defensive today, since the terminal-report checker only runs for exact-k ids). The driver gains --degenerate-exclusions as a review-time override, wired through the distinct-path check. Live receipt: the full-scale evidence run at seed 7 evaluated the battery with the register active and degenerate_release_surface passed with the exclusion recorded; only weight_ratio (1592.18 vs the 1151.25 maximum, its own #630 adjudication) still fails at full scale. Refs #630, #627, #610. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Resolves the TODO(PR #610 review): every admitted exclusion now records who approved it, the reasoning, the adjudication it descends from, when it was approved, and when it expires. One frozen UKReviewedExclusion record, one validator, one loader — shared by all three registers and any future entry; nothing in the schema knows about source_year. The whole record is sealed into the policy digest (74c9cd -> 2dbd78 -> ae93bd10 across this PR's commits), so editing an approver or extending an expiry moves the pinned literal. Expiry is enforced at gate evaluation with an injected clock — never at load, where the committed registers import at module load and a lapse date would brick every build at once. An entry is honored through expires_on; strictly after it, the exclusion stops suppressing and the gate fails with one combined renew-or-remove message naming the approver, the adjudication, and the lapse date. Details gain additive expired_exclusions and exclusions_evaluated_on keys on all three gates, and the contract requires expired_exclusions to be empty on published reports (absent fields default to empty, so grandfathered reports stay total). The shared US-consumed gate layer and the contract's flat str->str pin on QRF exclusion details are untouched: the UK wrappers project records down to plain reasons for non-expired entries before delegating, and withhold expired ones so the underlying failure fires beside the expiry context. The degenerate gate consumes records directly and its nested details entries carry the approver, adjudication, and expiry. The source_year entry keeps Max's #630 adjudication with the reason corrected to the verified mechanics: the column is derivable from the artifact's time_period on a single-vintage build and is retained for row-level vintage documentation and UK/US schema symmetry (the US pools three ASEC vintages, where the same column is irreducible per-row identity). approved_by juaristi22, expires 2027-02-10 — a plain six-month review, since multi-vintage stacking would auto-retire the entry through the stale discipline regardless. Refs #610, #630, #609. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The committed uk/degenerate_reviewed_exclusions.json register is the degenerate-release-surface policy of record, carrying the #630 adjudication: the column is constant at the build vintage by construction — lineage plumbing for the rowwise clone's source_household_key that documents the vintage at the row level, not signal. A None argument to uk_terminal_gate_report resolves to the register (pass {} to run bare); stale entries still fail the gate, so the register cannot rot. Interim schema-1, matching the two existing registers; all three upgrade together when the #610 approval-identity/ receipt/expiry design lands. The frozen policy digest moved with the register — the intended tripwire — and the microcosm-data pin is now vintage-aware: new releases must attest the #630 policy while the grandfathered June release keeps attesting its own pre-#630 digest (defensive today, since the terminal-report checker only runs for exact-k ids). The driver gains --degenerate-exclusions as a review-time override, wired through the distinct-path check. Live receipt: the full-scale evidence run at seed 7 evaluated the battery with the register active and degenerate_release_surface passed with the exclusion recorded; only weight_ratio (1592.18 vs the 1151.25 maximum, its own #630 adjudication) still fails at full scale. Refs #630, #627, #610. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Resolves the TODO(PR #610 review): every admitted exclusion now records who approved it, the reasoning, the adjudication it descends from, when it was approved, and when it expires. One frozen UKReviewedExclusion record, one validator, one loader — shared by all three registers and any future entry; nothing in the schema knows about source_year. The whole record is sealed into the policy digest (74c9cd -> 2dbd78 -> ae93bd10 across this PR's commits), so editing an approver or extending an expiry moves the pinned literal. Expiry is enforced at gate evaluation with an injected clock — never at load, where the committed registers import at module load and a lapse date would brick every build at once. An entry is honored through expires_on; strictly after it, the exclusion stops suppressing and the gate fails with one combined renew-or-remove message naming the approver, the adjudication, and the lapse date. Details gain additive expired_exclusions and exclusions_evaluated_on keys on all three gates, and the contract requires expired_exclusions to be empty on published reports (absent fields default to empty, so grandfathered reports stay total). The shared US-consumed gate layer and the contract's flat str->str pin on QRF exclusion details are untouched: the UK wrappers project records down to plain reasons for non-expired entries before delegating, and withhold expired ones so the underlying failure fires beside the expiry context. The degenerate gate consumes records directly and its nested details entries carry the approver, adjudication, and expiry. The source_year entry keeps Max's #630 adjudication with the reason corrected to the verified mechanics: the column is derivable from the artifact's time_period on a single-vintage build and is retained for row-level vintage documentation and UK/US schema symmetry (the US pools three ASEC vintages, where the same column is irreducible per-row identity). approved_by juaristi22, expires 2027-02-10 — a plain six-month review, since multi-vintage stacking would auto-retire the entry through the stale discipline regardless. Refs #610, #630, #609. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…610) Addresses vahid-ahmadi's review of #658: - UK_DEFAULT_DEGENERATE_REVIEWED_EXCLUSIONS and UK_TERMINAL_GATE_POLICY_SHA256 become cached accessors (uk_default_degenerate_reviewed_exclusions / uk_terminal_gate_policy_sha256) returning a MappingProxyType-wrapped register: the policy of record can no longer be mutated out from under the already-computed digest, and importing terminal_gates no longer reads the filesystem — a broken committed register surfaces as a clear ValueError at first use instead of an ImportError. The digest value is unchanged (ae93bd10…): laziness moves when the payload is read, not what it contains. - The register-of-record test drops its prose pins ("schema symmetry", "derivable") — rewording the reason still moves the frozen digest, so the durable assertions are the structural receipt fields plus the microcosm#630 adjudication. - New regression test: the register is loaded once, is immutable, and the digest is stable across calls. - The unrelated failures.append reflow in contract.py is reverted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…610) Three independent adversarial lenses reviewed the PR; every cross-validated finding lands here. The policy digest is unchanged (ae93bd10…) — these fix behavior and validation, not the sealed payload. - The degenerate gate now fails ANY out-of-force exclusion — dormant, signal-regained, or live — with receipt context, matching the input-mass and QRF wrappers (all three lenses found the dormant-expired silent pass: a green build would publish a report every consumer then rejects). - Receipts gain an in-force window: an entry whose approved_on is still in the future is not an approval and never suppresses (previously fail-open for a typo'd future year). New premature_exclusions details key on all three gates, with contract expectations. - Receipt dates must be canonical YYYY-MM-DD (fromisoformat also accepts compact and week-date forms; the raw string is sealed, so two spellings of one date minted two digests) and text fields must be trimmed. - uk_terminal_gate_report coerces and freezes the register once at entry: the gate and the attested digest can no longer observe different contents when caller-controlled evaluators mutate the mapping mid-report. - The driver loads the degenerate register (override or committed preflight) before the destructive sidecar unlinks — a typo'd --degenerate-exclusions path no longer destroys the previous build's evidence first — and the build record notes committed-vs-override register provenance (content-addressed digests can't show it). - Contract: the three new detail fields join the required schema (a key-signed report can no longer omit them to dodge the empty-list expectations, and the absent-field defaults that had silently weakened four pre-existing checks revert to strict); exclusion-consuming gates must share one exclusions_evaluated_on; the legacy policy pin gains a lockstep test (it was asserted nowhere). - exclusion_evaluation_date() refuses datetimes (a date subclass that compared timestamps against dates or leaked a timestamp into details). - Stale Mapping[str, str] annotation on build_uk_national_dataset fixed. Refuted during review (no change needed): duplicate JSON keys are rejected at every nesting level by the loader's object_pairs_hook; the attested policy_sha256 is recomputed from the actual register in force, so an override cannot ride the certified pin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The committed uk/degenerate_reviewed_exclusions.json register is the degenerate-release-surface policy of record, carrying the #630 adjudication: the column is constant at the build vintage by construction — lineage plumbing for the rowwise clone's source_household_key that documents the vintage at the row level, not signal. A None argument to uk_terminal_gate_report resolves to the register (pass {} to run bare); stale entries still fail the gate, so the register cannot rot. Interim schema-1, matching the two existing registers; all three upgrade together when the #610 approval-identity/ receipt/expiry design lands. The frozen policy digest moved with the register — the intended tripwire — and the microcosm-data pin is now vintage-aware: new releases must attest the #630 policy while the grandfathered June release keeps attesting its own pre-#630 digest (defensive today, since the terminal-report checker only runs for exact-k ids). The driver gains --degenerate-exclusions as a review-time override, wired through the distinct-path check. Live receipt: the full-scale evidence run at seed 7 evaluated the battery with the register active and degenerate_release_surface passed with the exclusion recorded; only weight_ratio (1592.18 vs the 1151.25 maximum, its own #630 adjudication) still fails at full scale. Refs #630, #627, #610. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Resolves the TODO(PR #610 review): every admitted exclusion now records who approved it, the reasoning, the adjudication it descends from, when it was approved, and when it expires. One frozen UKReviewedExclusion record, one validator, one loader — shared by all three registers and any future entry; nothing in the schema knows about source_year. The whole record is sealed into the policy digest (74c9cd -> 2dbd78 -> ae93bd10 across this PR's commits), so editing an approver or extending an expiry moves the pinned literal. Expiry is enforced at gate evaluation with an injected clock — never at load, where the committed registers import at module load and a lapse date would brick every build at once. An entry is honored through expires_on; strictly after it, the exclusion stops suppressing and the gate fails with one combined renew-or-remove message naming the approver, the adjudication, and the lapse date. Details gain additive expired_exclusions and exclusions_evaluated_on keys on all three gates, and the contract requires expired_exclusions to be empty on published reports (absent fields default to empty, so grandfathered reports stay total). The shared US-consumed gate layer and the contract's flat str->str pin on QRF exclusion details are untouched: the UK wrappers project records down to plain reasons for non-expired entries before delegating, and withhold expired ones so the underlying failure fires beside the expiry context. The degenerate gate consumes records directly and its nested details entries carry the approver, adjudication, and expiry. The source_year entry keeps Max's #630 adjudication with the reason corrected to the verified mechanics: the column is derivable from the artifact's time_period on a single-vintage build and is retained for row-level vintage documentation and UK/US schema symmetry (the US pools three ASEC vintages, where the same column is irreducible per-row identity). approved_by juaristi22, expires 2027-02-10 — a plain six-month review, since multi-vintage stacking would auto-retire the entry through the stale discipline regardless. Refs #610, #630, #609. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…610) Addresses vahid-ahmadi's review of #658: - UK_DEFAULT_DEGENERATE_REVIEWED_EXCLUSIONS and UK_TERMINAL_GATE_POLICY_SHA256 become cached accessors (uk_default_degenerate_reviewed_exclusions / uk_terminal_gate_policy_sha256) returning a MappingProxyType-wrapped register: the policy of record can no longer be mutated out from under the already-computed digest, and importing terminal_gates no longer reads the filesystem — a broken committed register surfaces as a clear ValueError at first use instead of an ImportError. The digest value is unchanged (ae93bd10…): laziness moves when the payload is read, not what it contains. - The register-of-record test drops its prose pins ("schema symmetry", "derivable") — rewording the reason still moves the frozen digest, so the durable assertions are the structural receipt fields plus the microcosm#630 adjudication. - New regression test: the register is loaded once, is immutable, and the digest is stable across calls. - The unrelated failures.append reflow in contract.py is reverted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…610) Three independent adversarial lenses reviewed the PR; every cross-validated finding lands here. The policy digest is unchanged (ae93bd10…) — these fix behavior and validation, not the sealed payload. - The degenerate gate now fails ANY out-of-force exclusion — dormant, signal-regained, or live — with receipt context, matching the input-mass and QRF wrappers (all three lenses found the dormant-expired silent pass: a green build would publish a report every consumer then rejects). - Receipts gain an in-force window: an entry whose approved_on is still in the future is not an approval and never suppresses (previously fail-open for a typo'd future year). New premature_exclusions details key on all three gates, with contract expectations. - Receipt dates must be canonical YYYY-MM-DD (fromisoformat also accepts compact and week-date forms; the raw string is sealed, so two spellings of one date minted two digests) and text fields must be trimmed. - uk_terminal_gate_report coerces and freezes the register once at entry: the gate and the attested digest can no longer observe different contents when caller-controlled evaluators mutate the mapping mid-report. - The driver loads the degenerate register (override or committed preflight) before the destructive sidecar unlinks — a typo'd --degenerate-exclusions path no longer destroys the previous build's evidence first — and the build record notes committed-vs-override register provenance (content-addressed digests can't show it). - Contract: the three new detail fields join the required schema (a key-signed report can no longer omit them to dodge the empty-list expectations, and the absent-field defaults that had silently weakened four pre-existing checks revert to strict); exclusion-consuming gates must share one exclusions_evaluated_on; the legacy policy pin gains a lockstep test (it was asserted nowhere). - exclusion_evaluation_date() refuses datetimes (a date subclass that compared timestamps against dates or leaked a timestamp into details). - Stale Mapping[str, str] annotation on build_uk_national_dataset fixed. Refuted during review (no change needed): duplicate JSON keys are rejected at every nesting level by the loader's object_pairs_hook; the attested policy_sha256 is recomputed from the actual register in force, so an override cannot ride the certified pin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes #609. Increment 4 of the #578 parity plan: the UK terminal battery gains the two US integrity gates that were each purchased with a named incident — input-mass parity (#278) and QRF tail concentration (#462) — under the attested, batched, fail-closed report from increment 1 (#588).
What landed (deliverables 1–5 of #609)
Shared helper promoted, not forked (deliverable 1).
us_input_mass_totalsmoved topopulace.build.input_mass.input_mass_totals; theus_runtimepath re-exports it, so US call sites are byte-identical. A UK analoguk_dataset_input_mass_totalshandles the national person/benunit/household table layout (household weights broadcast through membership; names namespacedentity.columnper the degenerate-gate convention, since national tables don't enforce globally unique column names).uk_input_mass_parity_gate(deliverable 1). Wraps the shared gate verbatim — zero candidate mass fails at any tolerance, candidate-only columns are reported and never fail, near-zero reference columns are skipped, unused entries are reported — and adds the universal exclusion discipline the shared gate lacks: a stale exclusion (column now within tolerance) fails, via a per-column probe that reuses the shared gate's exact semantics; below-floor and absent-from-reference entries are dormant and reported. The frozen reference's filename, revision, sha256, and vintage are required byUKInputMassReference, recorded in the gate details, and bound into the evidence digest.uk_qrf_tail_concentration_gate(deliverable 2). Surface derived from thefit_weighted_qrf_stage*operations' declaredoutputsinhmrc_income_source_stages.jsonvia a newuk_hmrc_weighted_qrf_output_columns()in the source-contract module (47 columns today; a new manifest-declared QRF output is covered the day it's declared). Per the issue, no sparsity filter: every declared, present, numeric output is checked;min_nonzero_recordsis the sole thinness guard. Stale/dormant discipline comes from the shared gate.Wiring (deliverable 3). Both gates follow the increment-1 optional-evidence pattern: a path that supplies no reference/thresholds omits them (never a placeholder pass), and an armed gate missing either fails closed with a named line. Policies are sealed into
policy_sha256(_terminal_gate_policy_payloadgainedinput_mass_parity/qrf_tail_concentrationslots); two newevidence_sha256families follow the existing precedents (input-mass hashes the frozen reference identity+totals; QRF tail projects the gate's recorded observables, likerelease_datasetdoes). Report schema 2→3, attestation 4→5, membership validation extended. Evidence artifacts still land before the batched raise — a red run leaves the signed diagnosis on disk (verified). No second raise added.Registers (deliverable 4). Committed empty registers (
uk/input_mass_reviewed_exclusions.json,uk/qrf_tail_reviewed_exclusions.json) declared in the country package, loaded by default by the new--input-mass-*/--qrf-tail-*flags ontools/build_uk_national_dataset.py(complete-arming enforced: partial threshold sets are a CLI error). Mandatory non-empty reason, dormant reported, stale fails.Mutation tests (deliverable 5). A zeroed column, a 99.9%-loss column, and a concentrated column each fail by name through the armed battery; a crash inside either evaluator appends a named failure without masking pending gates (#547); dropping an evidenced gate fails attested-membership validation and a committed membership test; stale exclusions fail in both gates; thresholds provably move
policy_sha256; legacy no-evidence paths keep the exact five-gate membership.Publication contract in lockstep.
populace-data/contract.pypins moved with the producer (schema 3, attestation 5, new policy digest, evidence-stage→gate map, detail schemas, stale-exclusion and reference-identity observables). The pinned policy digest is the certified default policy, in which the increment-4 slots are unarmed — arming a published release requires a reviewed pin move alongside the committed threshold constants, which is exactly the "a threshold outside the policy hash is not attested" rule.Deliverable 0 (measurement pass) — tooling shipped, numbers deferred
No licensed UK artifact exists on this machine, so the measurement pass itself could not run here. What shipped so it can run anywhere with gated data:
tools/measure_uk_weighted_integrity_baselines.py— weighted per-column totals plus top-{10,100,500,1000} concentration and carrier counts for any national H5 (certified compact, staging), including a ready-to-fill reference-JSON template.tools/build_uk_efrs_parity_reference.py --emit-weighted-totals PATH— the pinned, sha-verified eFRS incumbent's totals in the exact schema the gate consumes. It refuses to write inside the repository.Licensing: resolved, and the recorder enforces it
The EUL question is answered from the licence text rather than left open. End User Licence CD137 v16.00 (25 February 2026) clause 8 requires "adhere[nce] to the statistical disclosure control standards as detailed in the Research data handling and security guide for users in any outputs I produce and publish" — publishing outputs is permitted, conditioned on SDC. Clause 4's bar on sharing "any Dataset(s) derived from the Data Collection(s)" governs record-level derived data (clause 13's deposit duty reads the same way), not summary statistics. Clauses 11–12 require acknowledgement and citation in any publication.
CD171-ResearchDataHandling v17.00 §5.2.1 supplies the rules: minimum threshold of 3 (10 advised against secondary disclosure, 30 for some studies), and "any output that refers to unit records, e.g. a maximum or minimum value, must be avoided".
So population-level weighted per-column totals are publishable with citation. But the first version of the recorder emitted a per-column
max_abs_valueand would have reported a top-k share for a column with a handful of carriers — and tail concentration is the (n, k) dominance statistic, so at small k the share times the total approximates the mean of those few records. Fixed in fdbcbb0: no maxima or minima, thin columns suppressed below--sdc-minimum-count(default 10),--top-knarrower than that count refused, and the applied rules plus citation obligations recorded in the payload. A genuinely concentrated column above the threshold stays fully visible, so the #462 signature remains detectable.Still the caller's to confirm: the SPI PUT (SN 9422) carries its own SPI usage agreement as a Special Condition under clause 3, with HMRC's statutory taxes-confidentiality duties behind it. Raise
--sdc-minimum-countto 30 if it or the FRS terms require it.Measurement pass: run, and it is why the gates ship unarmed
The pass ran against the pinned incumbent (
enhanced_frs_2023_24.h5, sha584ae33d…verified before reading) and the certified compact (populace_uk_2023.h5, shaf17306cc…verified). Four results, all recorded beside the policy dataclasses so no later reader reintroduces the US constants:top_k=100/min_nonzero_records=500settings the incumbent exceeds 0.75 on 16 of 28 checked columns. UK reported-benefit columns sit on small, high-intensity subpopulations, so concentrated weighted mass is their normal state.min_nonzero_records=5000, dropping 17 of 31 columns.0.0. The US1e9would stop checking 50 of 131 columns, includingperson.gift_aid(£996M) andperson.charitable_investment_gifts— the tworelease_input_coverage_manifest.jsonnames as distributional-required. The surface mixes units (currency into the trillions, weighted person counts, flag counts), so a magnitude floor is not a coherent materiality filter;0.0still skips the three exact-zero reference columns because the shared gate compares<=.gift_aidandcharitable_investment_giftsand lacks all elevenhmrc_spi_*columns, because it is the input to the HMRC/SPI stage that creates them. Comparing the two reproduces the Export input-mass-parity gate references the raw base, flagging correct calibration gains on PUF-imputed columns #327 failure mode on the restoration surface.Fifth result — the production release pair (added after review questions). The comparison production actually runs is the shipped release against the next shipping of the same kind — the
_export_input_mass_gatepairing whose reference #327 adjudicated to a certified release. Measured on the only consecutive certified UK pair (72aeefc2026-06-11 →dd68c732026-06-19, both sha-verified): zero columns dropped, zero zeroed, four added — the #278 incident class the gate exists for passes cleanly on a real shipping with no threshold judgment. Per-column drift, however, ran median 10.52% with a +2,973.71% maximum (person.adult_ema, intentional reported-benefit repopulation): any tolerance between 5% and 50% would have failed 22–89 columns of a reviewed shipping. The no-headroom release-arm boundary is therefore the pair's exact maximum,relative_tolerance = 29.737060withminimum_reference_total = 0.0, ratcheting down (never up) as consecutive same-code shippings stabilise, re-measured per certified pair. Adjudicated decision: the constant is deliberately not committed here — it lands with the release-arm call-site PR (items 6/8), because a threshold without the call site that exercises it cannot be attested in a report; the derivation lives in theweighted_integritydocstring so that PR arms from evidence.So both thresholds require a staged candidate, and the gates correctly remain evidence-gated and unarmed. The floor's derivation is recorded but not committed as a constant, because a policy arms both thresholds together; both land in the follow-up with the two lockstep digest moves.
Acknowledgement: figures derive from the enhanced Family Resources Survey artifact pinned at revision
655dd07e…, itself derived from the DWP Family Resources Survey distributed by the UK Data Service, and from the certified PolicyEngine UK compact. Per EUL clauses 11–12 the citation form specified in the study metadata applies to any onward publication.Still open (user-owned)
tools/build_uk_national_dataset.pyneeds the licensed UKDS SPI donorput2223uk.tab(SN 9422), the HMRC ODS surface, and a raw FRS directory. That is the only remaining blocker on arming either gate.UK_MAX_TO_MEDIAN_WEIGHT_RATIO, plusterminal_gates.UK_TERMINAL_GATE_POLICY_SHA256andcontract._UK_TERMINAL_GATE_POLICY_SHA256moved together.populace.data.download("uk", 2023)atpolicyengine/populace-uk-private, which 404s for a fine-grained token without explicitpolicyengineorg scope — the documented UK download path fails confusingly for org members whose token omits it._export_input_mass_gate): the UK dense/local epic: rowwise constituency-grain dataset by cloning the national staging pool (US dense-arm analog) #495 rowwise arm re-gates household weights only; both helpers are importable frompopulace.build.uk_runtimeand wire in when that arm carries full entity tables (items 6/8 territory).Verification
uv run pytest— full workspace suite green (includes 16 new weighted-integrity unit tests and 9 new battery tests).uv run ruff check .— clean.🤖 Generated with Claude Code